Skip to content

feat(pullsync): delivery batching performance analysis - #5540

Draft
janos wants to merge 1 commit into
masterfrom
pullsync-delivery-batching
Draft

feat(pullsync): delivery batching performance analysis#5540
janos wants to merge 1 commit into
masterfrom
pullsync-delivery-batching

Conversation

@janos

@janos janos commented Jul 21, 2026

Copy link
Copy Markdown
Member

Pullsync Batching Performance Analysis

This PR gives the overview of the performance testing and profiling conducted to evaluate the proposed batched chunk delivery (DeliveryBatch) against the legacy single-message delivery (v2.8.1) in the pullsync protocol. It should not be merged, as it is meant to reproduce the results, but it may be used for potential improvements.

The goal is to identify if the batching of delivery messages in the pullsync protocol can result to a speedup in overall deliveries.

To simulate real-world networking overhead accurately, a benchmark was written that spins up real libp2p nodes locally, multiplexes streams, and transmits chunks between a client and server.

This approach gives a convenient testing, benchmarking and profiling, but the pullsync performance can be measured on the real network as well.

There are two benchmarks:

  • BenchmarkDeliveryLibP2P - that uses the complete libp2p stack with real networking
  • BenchmarkDeliveryPipe - that just pipes the messages with buffers (no actual networking) which is not the best for real world network conditions but is good for algorithmic changes.

Benchmark

There are to benchmarks, one for he currently released bee v2.8.1 pullsync implementation and one for the proposed batched implementation. Both of them with 25 and 120 chunks delivered. Since the max batch size can be 25 chunks because of the limitation of 128KB payload size of protobuf. There is a possiblity to raise that limit, but with a danger to expose some vulnerability in the network.

Run the libp2p benchmark for 100 iterations tracking memory allocations:

go test -v -run=NONE -bench=BenchmarkDeliveryLibP2P -benchtime=100x -benchmem ./pkg/pullsync/delivery_test.go ./pkg/pullsync/pullsync_test.go
goos: darwin
goarch: arm64
cpu: Apple M1 Pro
BenchmarkDeliveryLibP2P
BenchmarkDeliveryLibP2P/n=25
BenchmarkDeliveryLibP2P/n=25/Batched
BenchmarkDeliveryLibP2P/n=25/Batched-10 	     100	  99077116 ns/op	  601238 B/op	    5915 allocs/op
BenchmarkDeliveryLibP2P/n=25/v2.8.1
BenchmarkDeliveryLibP2P/n=25/v2.8.1-10  	     100	  99083815 ns/op	  507315 B/op	    6076 allocs/op
BenchmarkDeliveryLibP2P/n=120
BenchmarkDeliveryLibP2P/n=120/Batched
BenchmarkDeliveryLibP2P/n=120/Batched-10         	     100	 475344458 ns/op	 2241125 B/op	   27103 allocs/op
BenchmarkDeliveryLibP2P/n=120/v2.8.1
BenchmarkDeliveryLibP2P/n=120/v2.8.1-10          	     100	 475295048 ns/op	 1690773 B/op	   27764 allocs/op
PASS
ok  	command-line-arguments	115.854s

Profiling

Ran Go's built-in pprof tools against both the Batched and v2.8.1 branches of the benchmark independently, to identify any bottlenecks and/or improvements.

Generate CPU and Memory profiles for the Batched implementation:

go test -run=NONE -bench='BenchmarkDeliveryLibP2P/n=120/Batched$' -benchtime=50x -cpuprofile=cpu_batched.prof -memprofile=mem_batched.prof ./pkg/pullsync/delivery_test.go ./pkg/pullsync/pullsync_test.go

Generate CPU and Memory profiles for the legacy v2.8.1 implementation:

go test -run=NONE -bench='BenchmarkDeliveryLibP2P/n=120/v2.8.1$' -benchtime=50x -cpuprofile=cpu_v281.prof -memprofile=mem_v281.prof ./pkg/pullsync/delivery_test.go ./pkg/pullsync/pullsync_test.go

Analyze:

go tool pprof -top cpu_batched.prof | head -n 15
go tool pprof -top cpu_v281.prof | head -n 15
go tool pprof -top mem_batched.prof | head -n 15
go tool pprof -top mem_v281.prof | head -n 15

CPU Profile

Both profiles reveal an identical bottleneck. In both implementations, runtime.pthread_cond_wait, runtime.usleep, and runtime.pthread_cond_signal consume the majority of all execution samples. This indicates the CPU is blocking on libp2p network I/O synchronization than performing protobuf serialization or algorithmic work.

Batched:

      flat  flat%   sum%        cum   cum%
     0.58s 42.96% 42.96%      0.58s 42.96%  runtime.pthread_cond_wait
     0.24s 17.78% 60.74%      0.24s 17.78%  runtime.usleep
     0.22s 16.30% 77.04%      0.22s 16.30%  runtime.pthread_cond_signal
     0.08s  5.93% 82.96%      0.08s  5.93%  syscall.rawsyscalln
     0.05s  3.70% 86.67%      0.05s  3.70%  golang.org/x/crypto/sha3.keccakF1600
     0.05s  3.70% 90.37%      0.05s  3.70%  runtime.kevent

v2.8.1 (Single Message):

      flat  flat%   sum%        cum   cum%
     0.54s 35.06% 35.06%      0.54s 35.06%  runtime.pthread_cond_wait
     0.25s 16.23% 51.30%      0.25s 16.23%  runtime.pthread_cond_signal
     0.20s 12.99% 64.29%      0.20s 12.99%  runtime.usleep
     0.10s  6.49% 70.78%      0.10s  6.49%  syscall.rawsyscalln
     0.09s  5.84% 76.62%      0.09s  5.84%  runtime.memclrNoHeapPointers
     0.08s  5.19% 81.82%      0.08s  5.19%  runtime.kevent
     0.06s  3.90% 85.71%      0.06s  3.90%  golang.org/x/crypto/sha3.keccakF1600

Memory Profile

Batched:

      flat  flat%   sum%        cum   cum%
   27.63MB 22.44% 22.44%    27.63MB 22.44%  github.com/ethersphere/bee/v2/pkg/pullsync/pb.(*Delivery).Unmarshal
   26.50MB 21.53% 43.97%    26.50MB 21.53%  golang.org/x/crypto/sha3.(*state).Sum
   17.63MB 14.32% 58.29%    20.23MB 16.44%  github.com/gogo/protobuf/io.(*varintWriter).WriteMsg
       5MB  4.06% 62.35%        5MB  4.06%  golang.org/x/crypto/sha3.NewLegacyKeccak256 (inline)
    4.42MB  3.59% 65.94%    32.55MB 26.44%  github.com/gogo/protobuf/io.(*varintReader).ReadMsg
    4.23MB  3.43% 69.38%     4.73MB  3.84%  github.com/libp2p/go-buffer-pool.(*BufferPool).Get

v2.8.1 (Single Message):

      flat  flat%   sum%        cum   cum%
   36.16MB 30.47% 30.47%    36.16MB 30.47%  github.com/ethersphere/bee/v2/pkg/pullsync/pb.(*Delivery).Unmarshal
   28.50MB 24.01% 54.48%    28.50MB 24.01%  golang.org/x/crypto/sha3.(*state).Sum
       7MB  5.90% 60.38%        7MB  5.90%  github.com/ethersphere/bee/v2/pkg/bmt.(*goroutineHasher).Write
    4.50MB  3.79% 64.17%    20.01MB 16.85%  github.com/ethersphere/bee-old/pkg/pullsync.(*Syncer).Sync
    4.50MB  3.79% 67.96%     4.50MB  3.79%  golang.org/x/crypto/sha3.NewLegacyKeccak256 (inline)
       3MB  2.53% 70.49%        3MB  2.53%  github.com/ethersphere/bee/v2/pkg/p2p/protobuf.Reader.ReadMsgWithContext

Both the batched and unbatched implementations perform nearly identically over libp2p (~99.07 ms vs ~99.08 ms for n=25 and ~475.34 ms vs ~475.29 ms for n=120).

The separated CPU profiles reveal why this is the case: the top CPU consumers in both implementations are OS-level thread blocking operations (runtime.pthread_cond_wait, runtime.usleep, runtime.kevent). These account for over 60-80% of all CPU samples depending on the run. Because the program is almost entirely bottlenecked by waiting for libp2p stream data to traverse network sockets, any optimizations gained by batching protobuf structures in memory are negligible compared to the network latency.

The memory profile shows that chunk parsing and cryptographic hashing (golang.org/x/crypto/sha3.(*state).Sum and pb.(*Delivery).Unmarshal) dominate memory allocations.

Even if the delimitedReaderMaxSize is raised to 1MB, allowing larger batches, no performance gains are visible.

Conclusion

Introducing a breaking protocol change to batch protobuf messages yields no statistical CPU performance gain over the multiplexer latency, so the simpler, single-message streaming approach is preferred.

I encourage everyone interested to validate the findings.

Checklist

  • I have read the coding guide.
  • My change requires a documentation update, and I have done it.
  • I have added tests to cover my changes.
  • I have filled out the description and linked the related issues.

Description

Open API Spec Version Changes (if applicable)

Motivation and Context (Optional)

Related Issue (Optional)

Screenshots (if appropriate):

AI Disclosure

  • This PR contains code that has been generated by an LLM.
  • I have reviewed the AI generated code thoroughly.
  • I possess the technical expertise to responsibly review the code generated in this PR.

@janos

janos commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

One comment regarding TCP (and UDP) Dynamics, a standard internet packet (MTU) is about 1500 bytes. A single 4 KB chunk already requires 3 TCP packets. Whether you flush chunks to the OS individually or in a batch, the OS TCP stack will still stream them out in 1500-byte packets at the maximum rate the network allows.

@janos

janos commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Until the real cluster measurements are performed, I have run a benchmarks on local machine, but using dnctl to simulate the latency on the network interface.

The benchmark that is executed is:

go test -bench=BenchmarkDeliveryLibP2P/n=120 -benchmem -benchtime=5x ./pkg/pullsync

Results without added latency:

goos: darwin
goarch: arm64
pkg: github.com/ethersphere/bee/v2/pkg/pullsync
cpu: Apple M1 Pro
BenchmarkDeliveryLibP2P/n=120/Batched-10               5         387043375 ns/op         2456576 B/op      27140 allocs/op
BenchmarkDeliveryLibP2P/n=120/v2.8.1-10                5         388048475 ns/op         1734056 B/op      27794 allocs/op
PASS

Than a latency is set with:

sudo dnctl pipe 1 config delay 200ms
echo "dummynet out from 127.0.0.1 to 127.0.0.1 pipe 1" | sudo pfctl -f - -e

And the benchmark is run again, the results are:

goos: darwin
goarch: arm64
pkg: github.com/ethersphere/bee/v2/pkg/pullsync
cpu: Apple M1 Pro
BenchmarkDeliveryLibP2P/n=120/Batched-10               5        1622241875 ns/op         2409558 B/op      27160 allocs/op
BenchmarkDeliveryLibP2P/n=120/v2.8.1-10                5        1625406400 ns/op         1730278 B/op      27831 allocs/op
PASS

If you want to reproduce the benchmarks on the local machine, do not forget to remove the latency:

sudo pfctl -F all -d
sudo dnctl -q flush

The results show that the latency has an impact on the performance of the pullsync algorithm, between two runs (with and without latency), but there are no differences between the batched and unbatched version of the algorithm. he latency penalty in pullsync is a TCP/IP layer phenomenon governed by the initial handshake and bandwidth delay, not an application-level delivery issue.

Both implementations suffer from the exact same latency penalty (TCP Slow Start): When a 200ms delay is injected, both versions see their delivery time increase from ~0.38s to ~1.62s. This ~1.2-second penalty is caused by the initial protocol handshake (the Get -> Offer -> Want) and the TCP congestion window (slow start) ramping up across a high-latency link.

Batching application-level data does not improve stream performance: With 200ms of latency, the Batched implementation (1.622s) and the v2.8.1 implementation (1.625s) are essentially identical. This proves that because legacy pullsync already streams data into the socket buffer rather than waiting for individual application-level ACKs, application-level batching provides no benefit. In both cases, the OS segments the stream into identical 1500-byte MTU packets and transmits them at the exact same rate.

While providing no speed benefit, the Batched implementation consistently consumes more memory (2.4 MB/op) compared to the legacy v2.8.1 implementation (1.7 MB/op). This is because the batched version must allocate and hold large arrays of chunk data in memory simultaneously before writing them to the socket buffer.

In this test, the batching protocol change introduces memory overhead without delivering any network speedup compared to the bee v2.8.1 pullsync implementation.

@janos

janos commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Measurements on the light testnet is performed by @gacevicljubisa and me.

The setup is to have only two nodes in the network running but not connecting to any other nodes, not even to themselves. One node is the uploader node and another node is the syncing node that share the same neighbourhood 0000000 (by using --target-neighborhood cli option on setup). First, a random file of 100MB length is uploaded to the uploader node (which is previously nuked), when it is done uploading, a connect call is performed to connect the uploader node and syncing node in order for pullsyncing to start. It is monitored that no other nodes are connected to the two nodes in the experiment. The connection after the file has been uploaded is ensuring that pushync is not interfering with the pullsync.

In total of 6 measurements are performed, 3 with bee v2.8.1 and 3 with the batching pullsyncer, implemented in this branch.

Kuberenetes cluster is used for running the bee nodes on light testnet.

The testing script, datasets and html interactive chart are attached to this comment.

The data show high variance in syncing speeds for either of the syncing algorithm. It also show a pattern when every run eventually drops to about 14.5 chunks per second which is most likely doe to disk I/O ceiling on kubernetes cluster. Before the drop, syncing speeds are significantly higher but with variances for both algorithms in the same ranges.

If we look at the pure transfer speed (the first 10,000 chunks) before the IO limits kick in:

  • Non-Batching (v2.8.1) Speeds: 84, 97, and 156 chunks/sec (Average = 112 chunks/sec)
  • Batching Speeds: 156, 89, and 44 chunks/sec (Average = 96 chunks/sec)

Statistically speaking, the variance (the swings between 44 and 156) completely destroys any statistical significance. Both implementations had one run hit ~156 chunks/sec, and both had runs dip below 90. The variance is most likely being driven entirely by the Kubernetes environment (noisy neighbors, CPU allocation, network jitter) rather than the protocol itself.

Because every single run eventually drops down to the ~14.5 chunks per second disk IO ceiling, the total completion time of the sync is almost entirely determined by how long the pod was allowed to burst before hitting the ceiling, rather than how fast the chunks were actually traveling over the network.

Conclusion: due to the extreme environmental noise in the Kubernetes cluster, the data is inconclusive. There is no statistically significant evidence from these specific runs that batching is better.

I would suggest to run the test on the environment with less noise, which wi could prove by having similar results on several runs of the same algorithm. And also to have the environment with local disks.

Chart: pullsync-timeseries.html to be opened in web browser for interactivity, but here is a static image as well:

chart

The testing script: pullsync_benchmark.sh

Data:
pullsync_metrics_batch_1785245830.csv
pullsync_metrics_batch_1785262487.csv
pullsync_metrics_batch_1785273181.csv
pullsync_metrics_v2.8.1_1785243656.csv
pullsync_metrics_v2.8.1_1785275681.csv
pullsync_metrics_v2.8.1_1785277405.csv

CSV files have first column as a timestamp and the third the number of delivered chunks as recorded per metric bee_pullsync_chunks_delivered.

Side note: We have also recorded the pullsyncRate as the third column in the csv files, and its value is completely unreliable. We did not investigate why. The value is calculated on the fly internally in bee and did not interfere with the results, but we did not try to address this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant